Repository navigation
Conversation
Input, textarea, checkbox, select and multi-select get the properties invalid and error. error shows a message under the control (part "error", announced with aria-live="polite") and marks the control as invalid: red border, aria-invalid and aria-describedby. The select triggers take the state from their select and keep a screen reader copy of the message, as aria-describedby can't cross shadow roots. Refs #535 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add server-agnostic utilities for RFC 9457 problem details: readProblemDetails() and parseProblemDetails() read the standard members, the field errors (errors[] with detail and pointer, as in the RFC's example; plain pointers and parameter accepted) and the extension members; mapFieldErrors() maps the errors to a component's fields by pointer or parameter (closest containing field otherwise) and gives a general error for the rest, for problem details without errors and for other responses. Refs #535 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a Field errors page (response format, utilities, example) and the invalid and error properties to the pages of the form controls. Refs #535 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- mapFieldErrors() takes parsed problem details only, instead of telling them from bodies by a hidden registry of parsed objects. - Field keys that are names of object members (e.g. toString) map correctly; locations in the general error have their keys unescaped (RFC 6901). - The checkbox message no longer widens or shifts the box next to its label; the select triggers' copy for aria-describedby is hidden, so screen readers don't read the message twice. - Docs: errors aren't passed down to contained fields; the example handles network errors; FOCA's problem type linked. - More tests for the select controls. Refs #535 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checkbox message no longer catches clicks meant for content below it, and the docs say that long messages don't wrap. mapFieldErrors() gives a general error for problem details without an errors array instead of throwing, e.g. for untyped callers. Refs #535 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideAPIDefine and export the RFC 9457 problem-details types and utilities, and expose them from the package root and the component-free
UIAdd invalid and error states to the form controls, including accessible error-message wiring and invalid styling on select triggers.
ConfigurationBuild and publish the component-free utilities entry, provide Node-resolution type mappings, and check that the built subpath loads without Lit or components.
DocumentationDocument the response format, utility APIs, form-control error properties, and the build-entry convention for component-free utilities.
TestsCover form-control error states and accessibility, utility parsing and mapping behavior, isolated subpath loading, and build-entry discovery.
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Documentation build overview
49 files changed ·
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## dev #576 +/- ##
==========================================
+ Coverage 73.53% 74.98% +1.45%
==========================================
Files 33 49 +16
Lines 8123 11131 +3008
Branches 381 582 +201
==========================================
+ Hits 5973 8347 +2374
- Misses 2141 2773 +632
- Partials 9 11 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Linked issuesChecked at commit 29d1097 #535 feat(design): show field errors from the API · 4 of 5 met
|
There was a problem hiding this comment.
Hey - I've found 9 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="packages/ecc-utils-design/src/utils/problem-details.ts" line_range="157" />
<code_context>
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ } catch {
+ this.generalError = 'The registry could not be reached.';
</code_context>
<issue_to_address>
**API response forges inherited extensions**
When an API response contains an object-valued `__proto__` extension and a caller later reads properties from `problem.extensions`, `extensions[key] = body[key]` invokes the `__proto__` setter when a response contains an object-valued `__proto__` extension, changing the prototype of the returned `extensions` object instead of storing that member as data. Downstream callers then see attacker-controlled inherited properties when reading extension values.
Build `extensions` with a null prototype or define extension keys as own data properties so `__proto__` cannot alter its prototype.
</issue_to_address>
### Comment 2
<location path="apps/documentation/pages/docs/design/field-errors.mdx" line_range="126-127" />
<code_context>
+ FIELDS,
+ `The service could not be registered (${response.status}).`,
+ );
+ this.fieldErrors = mapped.fieldErrors;
+ this.generalError = mapped.generalError ?? '';
+ }
+
</code_context>
<issue_to_address>
**Older responses overwrite newer errors**
When two submissions overlap and the older request resolves last, `submit()` applies the older response’s mapped errors after the newer submission completes, so the form shows outdated validation state or errors after a newer successful response.
Ignore a submission’s result when a newer submission has already started.
</issue_to_address>
### Comment 3
<location path="packages/ecc-utils-design/src/utils/problem-details.ts" line_range="118" />
<code_context>
+ return undefined;
+ }
+ }
+ if (plain !== "" && !plain.startsWith("/")) return undefined;
+ return plain;
+}
</code_context>
<issue_to_address>
**Malformed locations match the wrong field**
When an API returns a pointer with an invalid tilde escape that matches a configured field location, `normalizePointer()` checks only that the pointer starts with `/`, so it accepts invalid RFC 6901 escapes; `mapFieldErrors()` can then attach the error to the wrong field instead of treating it as unmatched.
Reject pointers containing any `~` not followed by `0` or `1`.
</issue_to_address>
### Comment 4
<location path="packages/ecc-utils-design/src/utils/problem-details.ts" line_range="152" />
<code_context>
+ */
+export function parseProblemDetails(body: unknown): ProblemDetails | undefined {
+ if (!isObject(body)) return undefined;
+ if (!["type", "title", "status", "detail", "errors"].some((m) => m in body))
+ return undefined;
+ const extensions: Record<string, unknown> = {};
</code_context>
<issue_to_address>
**Valid problems with only instance are discarded**
When a problem-details response contains `instance` but none of the other members checked by the recognition guard, `parseProblemDetails()` omits `instance` from its recognition check and returns `undefined`, so `readProblemDetails()` discards the valid problem and callers lose its occurrence URI and other parsed details.
Include `instance` among the members that qualify a body as problem details.
</issue_to_address>
### Comment 5
<location path="packages/ecc-utils-design/src/components/input/input.ts" line_range="53" />
<code_context>
const checkboxClasses = cn(
- "peer h-4 w-4 shrink-0 rounded-[4px] border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
+ "peer h-4 w-4 shrink-0 rounded-[4px] border shadow focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
+ isInvalid
+ ? "border-destructive focus-visible:ring-destructive"
+ : "border-primary focus-visible:ring-ring",
</code_context>
<issue_to_address>
**Input focus rings disappear**
When a user keyboard-focuses an input or textarea, keyboard focus on these controls has no visible ring: the new classes set only the ring color, while `focus-visible:outline-none` removes the browser outline and the old `focus-visible:ring-1` width utility was removed. Users lose the prior focus indicator on both controls.
Keep `focus-visible:ring-1` in the base classes for both controls.
Also at `packages/ecc-utils-design/src/components/textarea/textarea.ts:36`.
</issue_to_address>
### Comment 6
<location path="packages/ecc-utils-design/src/utils/problem-details.ts" line_range="257" />
<code_context>
+ .split("/")
+ .map((key) => key.replace(/~1/g, "/").replace(/~0/g, "~"))
+ .join("/");
+ return location ? `${location}: ${error.detail}` : error.detail;
+}
+
</code_context>
<issue_to_address>
**Empty-key locations are omitted**
When an unmatched error points to an object member with an empty-string key, `describeUnmatched()` treats the valid pointer `/` as an absent location, so the general error shows only the detail and omits which field it concerns.
Distinguish an absent location from a valid location that decodes to the empty string.
</issue_to_address>
### Comment 7
<location path="packages/ecc-utils-design/src/form-controls.test.ts" line_range="142" />
<code_context>
+ expect(button().getAttribute("aria-invalid")).to.equal("true");
+ const describedBy = button().getAttribute("aria-describedby")!;
+ expect(
+ trigger.shadowRoot!.getElementById(describedBy)!.textContent?.trim()
+ ).to.equal("Must be one of: a, b.");
+ expect(button().className).to.contain("border-destructive");
</code_context>
<issue_to_address>
**Duplicate error copy stays exposed**
When a select or multi-select displays an error, `shows the error and marks the trigger invalid` checks only the described-by copy's text at `form-controls.test.ts:142`; removing `hidden` from that copy still passes, leaving an extra exposed message that can be announced alongside the live-region error.
Assert that the described-by copy is hidden as well as containing the error text.
</issue_to_address>
### Comment 8
<location path="packages/ecc-utils-design/src/form-controls.test.ts" line_range="81-83" />
<code_context>
+ el.error = "";
+ await el.updateComplete;
+ const control = el.shadowRoot!.querySelector(selector)!;
+ expect(control.hasAttribute("aria-invalid")).to.equal(false);
+ expect(errorElement(el).textContent?.trim()).to.equal("");
+ expect(control.className).to.not.contain("border-destructive");
+ });
+ });
</code_context>
<issue_to_address>
**Stale error description passes**
When an error is cleared after being set, `clears the error` checks the invalid state, message text and border at `form-controls.test.ts:81-83`, and the select test checks only invalid state and border at `:149-150`; both pass if `aria-describedby="error"` remains after clearing, leaving assistive technology pointed at an empty message.
Assert that `aria-describedby` is absent after clearing the error on each control and select trigger.
Also at `packages/ecc-utils-design/src/form-controls.test.ts:149-150`.
</issue_to_address>
### Comment 9
<location path="packages/ecc-utils-design/src/utils/problem-details.test.ts" line_range="153" />
<code_context>
+ expect(problem?.errors).to.have.length(2);
+ expect(
+ await readProblemDetails(response(body, "application/json"))
+ ).to.not.equal(undefined);
+ });
+
</code_context>
<issue_to_address>
**JSON parsing accepts an empty result**
When the `application/json` branch returns a non-`undefined` result without parsing the body, when `readProblemDetails()` returns a non-`undefined` placeholder for an `application/json` body instead of parsing its problem details, `reads problem+json and JSON bodies` passes at `problem-details.test.ts:153`; it never checks the JSON response's parsed errors or other content.
Assert that the parsed `application/json` response contains the expected errors or another known member from `RFC_EXAMPLE`.
</issue_to_address>Sourcery assessment
Approval pending. 9 findings to address first.
Blocking findings: packages/ecc-utils-design/src/utils/problem-details.ts:157, apps/documentation/pages/docs/design/field-errors.mdx:127, packages/ecc-utils-design/src/utils/problem-details.ts:118, packages/ecc-utils-design/src/utils/problem-details.ts:152, packages/ecc-utils-design/src/components/input/input.ts:53, and 4 more
- Extension members and field keys named __proto__ are kept as own data instead of changing prototypes. - Pointers with invalid ~ escapes are rejected (RFC 6901). - Bodies with only instance are recognized as problem details. - The general error names the member with the empty key (/). - Docs example: only the latest submission shows its errors. - Tests check that described-by copies are hidden, that aria-describedby is removed with the error, and the parsed JSON. Refs #535 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a changeset for the design package, following the changeset convention that arrived on dev. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anuragxxd
left a comment
There was a problem hiding this comment.
Thanks, the problem-details part looks solid.
- Needed: please add a
./utilssubpath export (and a tsup entry). Right now the utilities are only exported from the root, which registers every component, including Ace. - Suggested: drop
errorfrom the checkbox and keep onlyinvalid. The message doesn't wrap, so it overflows when the checkbox is at the right of a row, as in the TES run-create form.
Add the subpath export @elixir-cloud/design/utils and build src/utils/index.ts as a tsup entry, so that apps can import the problem details utilities without registering every component (including the Ace-based code editor), as the package root does. The build entries move to getBuildEntries() in scripts/utils.js, with tests. The Field errors page imports from the subpath. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep only invalid on the checkbox: its message didn't wrap and overflowed when the checkbox is at the end of a row, as in the TES run-create form. Docs and changeset say to show the message elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add scripts/check-design-utils.mjs, run in CI after the build: the built @elixir-cloud/design/utils subpath must export the utilities and load neither Lit nor components. The build entry tests now clean up with t.after() and check the design package's real entries. Document the utils entry convention in the contributing guide and split the changeset into bullets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add typesVersions for the utils subpath, so that TypeScript projects with moduleResolution node, which ignores exports, find its types. Say in the docs how to show a checkbox's field error: its message is in fieldErrors, not in generalError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sourcery withdrew this approval because the latest commits introduced blocking findings.
|
@anuragxxd thanks, both points are addressed; ready for re-review:
|
Summary
Generic support in
@elixir-cloud/designfor showing the field errors that an API returns, without validation rules in the components:invalid(Boolean, reflected) anderror(string); the checkbox getsinvalidonly (per review: a message under the box doesn't wrap and overflows when the checkbox is at the end of a row).errorshows the message under the control (parterror,aria-live="polite") and marks the control as invalid: red border (destructivetoken),aria-invalid="true"andaria-describedbypointing to the message.invalidmarks it as invalid without a message. For select and multi-select, the properties are on the root element; the trigger shows the invalid state and keeps a hidden copy of the message foraria-describedby, which can't refer to another shadow root.src/utils/problem-details.ts, exported from the new subpath@elixir-cloud/design/utils, which registers no components, and from the package root), server-agnostic, for RFC 9457 (application/problem+json):readProblemDetails(response)/parseProblemDetails(body): standard members (typedefaulting toabout:blank,title,status,detail,instance), field errors in the form of the RFC's example (errors[]withdetailandpointeras URI fragment, e.g.#/organization/url; plain pointers andparameteraccepted too;codekept; each entry as received inraw), extension members (e.g.code) inextensions.mapFieldErrors(problem, fields, fallback?): maps the errors to the component's fields by pointer or parameter (exact match, else the closest containing field, e.g./tags/0to/tags). Errors that match no field, problem details withouterrorsand responses that aren't problem details givegeneralError(the unmatched messages with their locations, elsedetail/title, else the fallback).normalizePointer(pointer)../utilssubpath export in the design package'spackage.json, andsrc/utils/index.tsas a tsup entry. The entries move fromscripts/build.jstogetBuildEntries()inscripts/utils.js(unchanged for every package apart from the utils entry), with tests inscripts/utils.test.js.invalid/erroron the pages of the four controls,invalidon the checkbox page; the page imports the utilities from@elixir-cloud/design/utils.No client package is changed; components can adopt this in follow-ups (e.g. the Cloud Registry create form). Changeset:
.changeset/design-field-errors.md(@elixir-cloud/design: minor), following the convention that arrived ondevwith #516 (merged into this branch).Closes #535
Verification
npm run lint(repo root; builds all packages and the docs site, then ESLint): 19/19 tasks successful, no errors (warnings only, all pre-existing kinds).web-test-runner,--group default): Chromium 58 passed, Firefox 58 passed (1 skipped). New:src/form-controls.test.ts(states, aria wiring, attribute, clearing, select/multi-select triggers, trigger without select) andsrc/utils/problem-details.test.ts(pointer normalization, parsing incl. wrong member types and non-problem bodies,readProblemDetailscontent types, mapping incl. unknown pointers/parameters, ancestors, responses withouterrors, fallback, RFC 6901 escapes, field keys liketoString).GlobalStyles > code editor theme > draws fold arrows) failed locally on Chromium only because the sandbox blocks the Ace worker download from cdn.jsdelivr.net; unrelated to this change.dev): all checks pass (CI passed, Lint, Test on Node 22 and 24 incl. WebKit, Conflict markers, PR title, CodeQL, Codecov patch/project, Read the Docs, Sourcery review). Sourcery's 9 threads were addressed (8 fixed, 1 answered: the focus ring width is unchanged) and resolved; Sourcery approved.After the maintainer's review
Changes for @anuragxxd's review (commits 2f14e7e..29d1097):
./utilssubpath export (exportsplustypesVersionsfor TypeScript'snoderesolution) andsrc/utils/index.tsas a tsup entry; CI step "Check design utils entry" (scripts/check-design-utils.mjs) checks after the build that the subpath exports the utilities and loads neither Lit nor components (verified to fail when a component import is added).errorremoved,invalidkept; docs say how to show the message instead.Local checks:
node --test scripts/utils.test.js scripts/release.test.js32/32 pass; design package tests (web-test-runner --group default, Chromium and Firefox; WebKit can't be installed on Ubuntu 26.04, CI runs it) all pass;npm run lint(builds all packages and docs) 0 errors;node scripts/check-design-utils.mjsok;tscresolves@elixir-cloud/design/utilsundernode,node16andbundlerresolution. Reviewed by the adversarial and quality reviewer subagents (2 rounds).Declined review findings
Problem-details entries without a
detailare dropped (not shown at a field): RFC 9457's example and FOCA always senddetail; inventing a message would be guesswork.Error-state markup and logic is repeated in the five controls rather than shared: the repo's Tailwind build generates styles per component directory from that directory's files, so the classes must stay in each component, and the components already duplicate helpers (e.g.
cn) in the same way.The select roots call
requestUpdate()on their triggers instead of extending the polledselectState: it updates at once and is explained in a comment; the shared state is for open/value.A field key
__proto__would lose its message: not a realistic field key.field-errors.mdxis formatted with the docs app's Prettier settings (single quotes), while the component pages keep their existing style; reformatting those pages is out of scope.#-pointer errors without location are covered by the general-error test;normalizePointeris kept public and documented, as components need it to compare their own pointers.normalizePointerstays in the CI check's expected exports: it is documented as public on the Field errors page.typesVersionsfor/components/*,/reactand/events(also unresolvable under TypeScript'snoderesolution, as before this PR): separate improvement, not part of this review.Open questions
None.
🤖 Generated with Claude Code